import Vue from "../../utils/vue";
import NlyToastTransition from "../../utils/nly-toast-transition";
import KeyCodes from "../../utils/key-codes";
import identity from "../../utils/identity";
import observeDom from "../../utils/observe-dom";
import { arrayIncludes, concat } from "../../utils/array";
import { getComponentConfig } from "../../utils/config";
import {
  closest,
  contains,
  isVisible,
  requestAF,
  select,
  selectAll
} from "../../utils/dom";
import { isBrowser } from "../../utils/env";
import {
  EVENT_OPTIONS_NO_CAPTURE,
  eventOn,
  eventOff
} from "../../utils/events";
import { stripTags } from "../../utils/html";
import { isString, isUndefinedOrNull } from "../../utils/inspect";
import { HTMLElement } from "../../utils/safe-types";
import { NlyTransporterSingle } from "../../utils/transporter";
import idMixin from "../../mixins/id";
import listenOnDocumentMixin from "../../mixins/listen-on-document";
import listenOnRootMixin from "../../mixins/listen-on-root";
import listenOnWindowMixin from "../../mixins/listen-on-window";
import normalizeSlotMixin from "../../mixins/normalize-slot";
import scopedStyleAttrsMixin from "../../mixins/scoped-style-attrs";
import { NlyButton } from "../button/button";
import { NlyButtonClose } from "../button/button-close";
import { modalManager } from "./helpers/modal-manager";
import { NlyaModalEvent } from "./helpers/nly-modal-event.class";

// --- Constants ---

const NAME = "NlyModal";

const OBSERVER_CONFIG = {
  subtree: true,
  childList: true,
  characterData: true,
  attributes: true,
  attributeFilter: ["style", "class"]
};

// Query selector to find all tabbable elements
// (includes tabindex="-1", which we filter out after)
const TABABLE_SELECTOR = [
  "button",
  "[href]:not(.disabled)",
  "input",
  "select",
  "textarea",
  "[tabindex]",
  "[contenteditable]"
]
  .map(s => `${s}:not(:disabled):not([disabled])`)
  .join(", ");

// --- Utility methods ---

// Attempt to focus an element, and return true if successful
const attemptFocus = el => {
  if (el && isVisible(el) && el.focus) {
    try {
      el.focus();
      // eslint-disable-next-line no-empty
    } catch {}
  }
  // If the element has focus, then return true
  return document.activeElement === el;
};

// --- Props ---
export const props = {
  size: {
    type: String,
    default: () => getComponentConfig(NAME, "size")
  },
  centered: {
    type: Boolean,
    default: false
  },
  scrollable: {
    type: Boolean,
    default: false
  },
  buttonSize: {
    type: String
    // default: ''
  },
  noStacking: {
    type: Boolean,
    default: false
  },
  noFade: {
    type: Boolean,
    default: false
  },
  noCloseOnBackdrop: {
    type: Boolean,
    default: false
  },
  noCloseOnEsc: {
    type: Boolean,
    default: false
  },
  noEnforceFocus: {
    type: Boolean,
    default: false
  },
  ignoreEnforceFocusSelector: {
    type: [Array, String],
    default: ""
  },
  title: {
    type: String,
    default: ""
  },
  titleHtml: {
    type: String
  },
  titleTag: {
    type: String,
    default: () => getComponentConfig(NAME, "titleTag")
  },
  titleClass: {
    type: [String, Array, Object]
    // default: null
  },
  titleSrOnly: {
    type: Boolean,
    default: false
  },
  ariaLabel: {
    type: String
    // default: null
  },
  headerBgVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "headerBgVariant")
  },
  headerBorderVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "headerBorderVariant")
  },
  headerTextVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "headerTextVariant")
  },
  headerCloseVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "headerCloseVariant")
  },
  headerClass: {
    type: [String, Array, Object]
    // default: null
  },
  bodyBgVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "bodyBgVariant")
  },
  bodyTextVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "bodyTextVariant")
  },
  modalClass: {
    type: [String, Array, Object]
    // default: null
  },
  dialogClass: {
    type: [String, Array, Object]
    // default: null
  },
  contentClass: {
    type: [String, Array, Object]
    // default: null
  },
  bodyClass: {
    type: [String, Array, Object]
    // default: null
  },
  footerBgVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "footerBgVariant")
  },
  footerBorderVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "footerBorderVariant")
  },
  footerTextVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "footerTextVariant")
  },
  footerClass: {
    type: [String, Array, Object]
    // default: null
  },
  // TODO: Rename to `noHeader` and deprecate `hideHeader`
  hideHeader: {
    type: Boolean,
    default: false
  },
  // TODO: Rename to `noFooter` and deprecate `hideFooter`
  hideFooter: {
    type: Boolean,
    default: false
  },
  // TODO: Rename to `noHeaderClose` and deprecate `hideHeaderClose`
  hideHeaderClose: {
    type: Boolean,
    default: false
  },
  // TODO: Rename to `noBackdrop` and deprecate `hideBackdrop`
  hideBackdrop: {
    type: Boolean,
    default: false
  },
  okOnly: {
    type: Boolean,
    default: false
  },
  okDisabled: {
    type: Boolean,
    default: false
  },
  cancelDisabled: {
    type: Boolean,
    default: false
  },
  visible: {
    type: Boolean,
    default: false
  },
  returnFocus: {
    // HTML Element, CSS selector string or Vue component instance
    type: [HTMLElement, String, Object],
    default: null
  },
  headerCloseContent: {
    type: String,
    default: () => getComponentConfig(NAME, "headerCloseContent")
  },
  headerCloseLabel: {
    type: String,
    default: () => getComponentConfig(NAME, "headerCloseLabel")
  },
  cancelTitle: {
    type: String,
    default: () => getComponentConfig(NAME, "cancelTitle")
  },
  cancelTitleHtml: {
    type: String
  },
  okTitle: {
    type: String,
    default: () => getComponentConfig(NAME, "okTitle")
  },
  okTitleHtml: {
    type: String
  },
  cancelVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "cancelVariant")
  },
  okVariant: {
    type: String,
    default: () => getComponentConfig(NAME, "okVariant")
  },
  lazy: {
    type: Boolean,
    default: false
  },
  busy: {
    type: Boolean,
    default: false
  },
  static: {
    type: Boolean,
    default: false
  },
  autoFocusButton: {
    type: String,
    default: null,
    validator: val => {
      /* istanbul ignore next */
      return (
        isUndefinedOrNull(val) || arrayIncludes(["ok", "cancel", "close"], val)
      );
    }
  }
};

// @vue/component
export const NlyModal = /*#__PURE__*/ Vue.extend({
  name: NAME,
  mixins: [
    idMixin,
    listenOnDocumentMixin,
    listenOnRootMixin,
    listenOnWindowMixin,
    normalizeSlotMixin,
    scopedStyleAttrsMixin
  ],
  inheritAttrs: false,
  model: {
    prop: "visible",
    event: "change"
  },
  props,
  data() {
    return {
      isHidden: true, // If modal should not be in document
      isVisible: false, // Controls modal visible state
      isTransitioning: false, // Used for style control
      isShow: false, // Used for style control
      isBlock: false, // Used for style control
      isOpening: false, // To signal that the modal is in the process of opening
      isClosing: false, // To signal that the modal is in the process of closing
      ignoreBackdropClick: false, // Used to signify if click out listener should ignore the click
      isModalOverflowing: false,
      return_focus: this.returnFocus || null,
      // The following items are controlled by the modalManager instance
      scrollbarWidth: 0,
      zIndex: modalManager.getBaseZIndex(),
      isTop: true,
      isBodyOverflowing: false
    };
  },
  computed: {
    modalClasses() {
      return [
        {
          fade: !this.noFade,
          show: this.isShow
        },
        this.modalClass
      ];
    },
    modalStyles() {
      const sbWidth = `${this.scrollbarWidth}px`;
      return {
        paddingLeft:
          !this.isBodyOverflowing && this.isModalOverflowing ? sbWidth : "",
        paddingRight:
          this.isBodyOverflowing && !this.isModalOverflowing ? sbWidth : "",
        display: this.isBlock ? "block" : "none"
      };
    },
    dialogClasses() {
      return [
        {
          [`modal-${this.size}`]: this.size,
          "modal-dialog-centered": this.centered,
          "modal-dialog-scrollable": this.scrollable
        },
        this.dialogClass
      ];
    },
    headerClasses() {
      return [
        {
          [`bg-${this.headerBgVariant}`]: this.headerBgVariant,
          [`text-${this.headerTextVariant}`]: this.headerTextVariant,
          [`border-${this.headerBorderVariant}`]: this.headerBorderVariant
        },
        this.headerClass
      ];
    },
    titleClasses() {
      return [{ "sr-only": this.titleSrOnly }, this.titleClass];
    },
    bodyClasses() {
      return [
        {
          [`bg-${this.bodyBgVariant}`]: this.bodyBgVariant,
          [`text-${this.bodyTextVariant}`]: this.bodyTextVariant
        },
        this.bodyClass
      ];
    },
    footerClasses() {
      return [
        {
          [`bg-${this.footerBgVariant}`]: this.footerBgVariant,
          [`text-${this.footerTextVariant}`]: this.footerTextVariant,
          [`border-${this.footerBorderVariant}`]: this.footerBorderVariant
        },
        this.footerClass
      ];
    },
    modalOuterStyle() {
      // Styles needed for proper stacking of modals
      return {
        position: "absolute",
        zIndex: this.zIndex
      };
    },
    slotScope() {
      return {
        ok: this.onOk,
        cancel: this.onCancel,
        close: this.onClose,
        hide: this.hide,
        visible: this.isVisible
      };
    },
    computeIgnoreEnforceFocusSelector() {
      // Normalize to an single selector with selectors separated by `,`
      return concat(this.ignoreEnforceFocusSelector)
        .filter(identity)
        .join(",")
        .trim();
    }
  },
  watch: {
    visible(newVal, oldVal) {
      if (newVal !== oldVal) {
        this[newVal ? "show" : "hide"]();
      }
    }
  },
  created() {
    // Define non-reactive properties
    this._observer = null;
  },
  mounted() {
    // Set initial z-index as queried from the DOM
    this.zIndex = modalManager.getBaseZIndex();
    // Listen for events from others to either open or close ourselves
    // and listen to all modals to enable/disable enforce focus
    this.listenOnRoot("nlya::show::modal", this.showHandler);
    this.listenOnRoot("nlya::hide::modal", this.hideHandler);
    this.listenOnRoot("nlya::toggle::modal", this.toggleHandler);
    // Listen for `nlya:modal::show events`, and close ourselves if the
    // opening modal not us
    this.listenOnRoot("nlya::modal::show", this.modalListener);
    // Initially show modal?
    if (this.visible === true) {
      this.$nextTick(this.show);
    }
  },
  beforeDestroy() {
    // Ensure everything is back to normal
    if (this._observer) {
      this._observer.disconnect();
      this._observer = null;
    }
    if (this.isVisible) {
      this.isVisible = false;
      this.isShow = false;
      this.isTransitioning = false;
    }
  },
  methods: {
    // Private method to update the v-model
    updateModel(val) {
      if (val !== this.visible) {
        this.$emit("change", val);
      }
    },
    // Private method to create a nlyaModalEvent object
    buildEvent(type, options = {}) {
      return new NlyaModalEvent(type, {
        // Default options
        cancelable: false,
        target: this.$refs.modal || this.$el || null,
        relatedTarget: null,
        trigger: null,
        // Supplied options
        ...options,
        // Options that can't be overridden
        vueTarget: this,
        componentId: this.safeId()
      });
    },
    // Public method to show modal
    show() {
      if (this.isVisible || this.isOpening) {
        // If already open, or in the process of opening, do nothing
        /* istanbul ignore next */
        return;
      }
      /* istanbul ignore next */
      if (this.isClosing) {
        // If we are in the process of closing, wait until hidden before re-opening
        /* istanbul ignore next */
        this.$once("hidden", this.show);
        /* istanbul ignore next */
        return;
      }
      this.isOpening = true;
      // Set the element to return focus to when closed
      this.return_focus = this.return_focus || this.getActiveElement();
      const showEvt = this.buildEvent("show", {
        cancelable: true
      });
      this.emitEvent(showEvt);
      // Don't show if canceled
      if (showEvt.defaultPrevented || this.isVisible) {
        this.isOpening = false;
        // Ensure the v-model reflects the current state
        this.updateModel(false);
        return;
      }
      // Show the modal
      this.doShow();
    },
    // Public method to hide modal
    hide(trigger = "") {
      if (!this.isVisible || this.isClosing) {
        /* istanbul ignore next */
        return;
      }
      this.isClosing = true;
      const hideEvt = this.buildEvent("hide", {
        cancelable: trigger !== "FORCE",
        trigger: trigger || null
      });
      // We emit specific event for one of the three built-in buttons
      if (trigger === "ok") {
        this.$emit("ok", hideEvt);
      } else if (trigger === "cancel") {
        this.$emit("cancel", hideEvt);
      } else if (trigger === "headerclose") {
        this.$emit("close", hideEvt);
      }
      this.emitEvent(hideEvt);
      // Hide if not canceled
      if (hideEvt.defaultPrevented || !this.isVisible) {
        this.isClosing = false;
        // Ensure v-model reflects current state
        this.updateModel(true);
        return;
      }
      // Stop observing for content changes
      if (this._observer) {
        this._observer.disconnect();
        this._observer = null;
      }
      // Trigger the hide transition
      this.isVisible = false;
      // Update the v-model
      this.updateModel(false);
    },
    // Public method to toggle modal visibility
    toggle(triggerEl) {
      if (triggerEl) {
        this.return_focus = triggerEl;
      }
      if (this.isVisible) {
        this.hide("toggle");
      } else {
        this.show();
      }
    },
    // Private method to get the current document active element
    getActiveElement() {
      if (isBrowser) {
        const activeElement = document.activeElement;
        if (
          activeElement &&
          activeElement !== document.body &&
          activeElement.focus
        ) {
          // Preset the fallback return focus value if it is not set
          // `document.activeElement` should be the trigger element that was clicked or
          // in the case of using the v-model, which ever element has current focus
          // Will be overridden by some commands such as toggle, etc.
          return activeElement;
        }
      }
      return null;
    },
    // Private method to get a list of all tabable elements within modal content
    getTabables() {
      // Find all tabable elements in the modal content
      // Assumes users have not used tabindex > 0 on elements!
      return selectAll(TABABLE_SELECTOR, this.$refs.content)
        .filter(isVisible)
        .filter(i => i.tabIndex > -1 && !i.disabled);
    },
    // Private method to finish showing modal
    doShow() {
      /* istanbul ignore next: commenting out for now until we can test stacking */
      if (modalManager.modalsAreOpen && this.noStacking) {
        // If another modal(s) is already open, wait for it(them) to close
        this.listenOnRootOnce("nlya::modal::hidden", this.doShow);
        return;
      }
      modalManager.registerModal(this);
      // Place modal in DOM
      this.isHidden = false;
      this.$nextTick(() => {
        // We do this in `$nextTick()` to ensure the modal is in DOM first
        // before we show it
        this.isVisible = true;
        this.isOpening = false;
        // Update the v-model
        this.updateModel(true);
        this.$nextTick(() => {
          // In a nextTick in case modal content is lazy
          // Observe changes in modal content and adjust if necessary
          this._observer = observeDom(
            this.$refs.content,
            this.checkModalOverflow.bind(this),
            OBSERVER_CONFIG
          );
        });
      });
    },
    // Transition handlers
    onBeforeEnter() {
      this.isTransitioning = true;
      this.setResizeEvent(true);
    },
    onEnter() {
      this.isBlock = true;
      // We add the `show` class 1 frame later
      // `requestAF()` runs the callback before the next repaint, so we need
      // two calls to guarantee the next frame has been rendered
      requestAF(() => {
        requestAF(() => {
          this.isShow = true;
        });
      });
    },
    onAfterEnter() {
      this.checkModalOverflow();
      this.isTransitioning = false;
      // We use `requestAF()` to allow transition hooks to complete
      // before passing control over to the other handlers
      // This will allow users to not have to use `$nextTick()` or `requestAF()`
      // when trying to pre-focus an element
      requestAF(() => {
        this.emitEvent(this.buildEvent("shown"));
        this.setEnforceFocus(true);
        this.$nextTick(() => {
          // Delayed in a `$nextTick()` to allow users time to pre-focus
          // an element if the wish
          this.focusFirst();
        });
      });
    },
    onBeforeLeave() {
      this.isTransitioning = true;
      this.setResizeEvent(false);
      this.setEnforceFocus(false);
    },
    onLeave() {
      // Remove the 'show' class
      this.isShow = false;
    },
    onAfterLeave() {
      this.isBlock = false;
      this.isTransitioning = false;
      this.isModalOverflowing = false;
      this.isHidden = true;
      this.$nextTick(() => {
        this.isClosing = false;
        modalManager.unregisterModal(this);
        this.returnFocusTo();
        // TODO: Need to find a way to pass the `trigger` property
        //       to the `hidden` event, not just only the `hide` event
        this.emitEvent(this.buildEvent("hidden"));
      });
    },
    // Event emitter
    emitEvent(nlyaModalEvt) {
      const type = nlyaModalEvt.type;
      // We emit on root first incase a global listener wants to cancel
      // the event first before the instance emits its event
      this.emitOnRoot(
        `nlya::modal::${type}`,
        nlyaModalEvt,
        nlyaModalEvt.componentId
      );
      this.$emit(type, nlyaModalEvt);
    },
    // UI event handlers
    onDialogMousedown() {
      // Watch to see if the matching mouseup event occurs outside the dialog
      // And if it does, cancel the clickOut handler
      const modal = this.$refs.modal;
      const onceModalMouseup = evt => {
        eventOff(modal, "mouseup", onceModalMouseup, EVENT_OPTIONS_NO_CAPTURE);
        if (evt.target === modal) {
          this.ignoreBackdropClick = true;
        }
      };
      eventOn(modal, "mouseup", onceModalMouseup, EVENT_OPTIONS_NO_CAPTURE);
    },
    onClickOut(evt) {
      if (this.ignoreBackdropClick) {
        // Click was initiated inside the modal content, but finished outside.
        // Set by the above onDialogMousedown handler
        this.ignoreBackdropClick = false;
        return;
      }
      // Do nothing if not visible, backdrop click disabled, or element
      // that generated click event is no longer in document body
      if (
        !this.isVisible ||
        this.noCloseOnBackdrop ||
        !contains(document.body, evt.target)
      ) {
        return;
      }
      // If backdrop clicked, hide modal
      if (!contains(this.$refs.content, evt.target)) {
        this.hide("backdrop");
      }
    },
    onOk() {
      this.hide("ok");
    },
    onCancel() {
      this.hide("cancel");
    },
    onClose() {
      this.hide("headerclose");
    },
    onEsc(evt) {
      // If ESC pressed, hide modal
      if (
        evt.keyCode === KeyCodes.ESC &&
        this.isVisible &&
        !this.noCloseOnEsc
      ) {
        this.hide("esc");
      }
    },
    // Document focusin listener
    focusHandler(evt) {
      // If focus leaves modal content, bring it back
      const content = this.$refs.content;
      const { target } = evt;
      if (
        this.noEnforceFocus ||
        !this.isTop ||
        !this.isVisible ||
        !content ||
        document === target ||
        contains(content, target) ||
        (this.computeIgnoreEnforceFocusSelector &&
          closest(this.computeIgnoreEnforceFocusSelector, target, true))
      ) {
        return;
      }
      const tabables = this.getTabables();
      const { bottomTrap, topTrap } = this.$refs;
      if (bottomTrap && target === bottomTrap) {
        // If user pressed TAB out of modal into our bottom trab trap element
        // Find the first tabable element in the modal content and focus it
        if (attemptFocus(tabables[0])) {
          // Focus was successful
          return;
        }
      } else if (topTrap && target === topTrap) {
        // If user pressed CTRL-TAB out of modal and into our top tab trap element
        // Find the last tabable element in the modal content and focus it
        if (attemptFocus(tabables[tabables.length - 1])) {
          // Focus was successful
          return;
        }
      }
      // Otherwise focus the modal content container
      content.focus({ preventScroll: true });
    },
    // Turn on/off focusin listener
    setEnforceFocus(on) {
      this.listenDocument(on, "focusin", this.focusHandler);
    },
    // Resize listener
    setResizeEvent(on) {
      this.listenWindow(on, "resize", this.checkModalOverflow);
      this.listenWindow(on, "orientationchange", this.checkModalOverflow);
    },
    // Root listener handlers
    showHandler(id, triggerEl) {
      if (id === this.safeId()) {
        this.return_focus = triggerEl || this.getActiveElement();
        this.show();
      }
    },
    hideHandler(id) {
      if (id === this.safeId()) {
        this.hide("event");
      }
    },
    toggleHandler(id, triggerEl) {
      if (id === this.safeId()) {
        this.toggle(triggerEl);
      }
    },
    modalListener(nlyaEvt) {
      // If another modal opens, close this one if stacking not permitted
      if (this.noStacking && nlyaEvt.vueTarget !== this) {
        this.hide();
      }
    },
    // Focus control handlers
    focusFirst() {
      // Don't try and focus if we are SSR
      if (isBrowser) {
        requestAF(() => {
          const modal = this.$refs.modal;
          const content = this.$refs.content;
          const activeElement = this.getActiveElement();
          // If the modal contains the activeElement, we don't do anything
          if (
            modal &&
            content &&
            !(activeElement && contains(content, activeElement))
          ) {
            const ok = this.$refs["ok-button"];
            const cancel = this.$refs["cancel-button"];
            const close = this.$refs["close-button"];
            // Focus the appropriate button or modal content wrapper
            const autoFocus = this.autoFocusButton;
            const el =
              autoFocus === "ok" && ok
                ? ok.$el || ok
                : autoFocus === "cancel" && cancel
                ? cancel.$el || cancel
                : autoFocus === "close" && close
                ? close.$el || close
                : content;
            // Focus the element
            attemptFocus(el);
            if (el === content) {
              // Make sure top of modal is showing (if longer than the viewport)
              this.$nextTick(() => {
                modal.scrollTop = 0;
              });
            }
          }
        });
      }
    },
    returnFocusTo() {
      // Prefer `returnFocus` prop over event specified
      // `return_focus` value
      let el = this.returnFocus || this.return_focus || null;
      this.return_focus = null;
      this.$nextTick(() => {
        // Is el a string CSS selector?
        el = isString(el) ? select(el) : el;
        if (el) {
          // Possibly could be a component reference
          el = el.$el || el;
          attemptFocus(el);
        }
      });
    },
    checkModalOverflow() {
      if (this.isVisible) {
        const modal = this.$refs.modal;
        this.isModalOverflowing =
          modal.scrollHeight > document.documentElement.clientHeight;
      }
    },
    makeModal(h) {
      // Modal header
      let header = h();
      if (!this.hideHeader) {
        // TODO: Rename slot to `header` and deprecate `modal-header`
        let modalHeader = this.normalizeSlot("modal-header", this.slotScope);
        if (!modalHeader) {
          let closeButton = h();
          if (!this.hideHeaderClose) {
            closeButton = h(
              NlyButtonClose,
              {
                ref: "close-button",
                props: {
                  content: this.headerCloseContent,
                  disabled: this.isTransitioning,
                  ariaLabel: this.headerCloseLabel,
                  textVariant: this.headerCloseVariant || this.headerTextVariant
                },
                on: { click: this.onClose }
              },
              // TODO: Rename slot to `header-close` and deprecate `modal-header-close`
              [this.normalizeSlot("modal-header-close")]
            );
          }
          const domProps =
            // TODO: Rename slot to `title` and deprecate `modal-title`
            !this.hasNormalizedSlot("modal-title") && this.titleHtml
              ? { innerHTML: this.titleHtml }
              : {};
          modalHeader = [
            h(
              this.titleTag,
              {
                staticClass: "modal-title",
                class: this.titleClasses,
                attrs: { id: this.safeId("__nlya_modal_title_") },
                domProps
              },
              // TODO: Rename slot to `title` and deprecate `modal-title`
              [
                this.normalizeSlot("modal-title", this.slotScope) ||
                  stripTags(this.title)
              ]
            ),
            closeButton
          ];
        }
        header = h(
          "header",
          {
            ref: "header",
            staticClass: "modal-header",
            class: this.headerClasses,
            attrs: { id: this.safeId("__nlya_modal_header_") }
          },
          [modalHeader]
        );
      }

      // Modal body
      const body = h(
        "div",
        {
          ref: "body",
          staticClass: "modal-body",
          class: this.bodyClasses,
          attrs: { id: this.safeId("__nlya_modal_body_") }
        },
        this.normalizeSlot("default", this.slotScope)
      );

      // Modal footer
      let footer = h();
      if (!this.hideFooter) {
        // TODO: Rename slot to `footer` and deprecate `modal-footer`
        let modalFooter = this.normalizeSlot("modal-footer", this.slotScope);
        if (!modalFooter) {
          let cancelButton = h();
          if (!this.okOnly) {
            const cancelHtml = this.cancelTitleHtml
              ? { innerHTML: this.cancelTitleHtml }
              : null;
            cancelButton = h(
              NlyButton,
              {
                ref: "cancel-button",
                props: {
                  variant: this.cancelVariant,
                  size: this.buttonSize,
                  disabled:
                    this.cancelDisabled || this.busy || this.isTransitioning
                },
                on: { click: this.onCancel }
              },
              [
                // TODO: Rename slot to `cancel-button` and deprecate `modal-cancel`
                this.normalizeSlot("modal-cancel") ||
                  (cancelHtml
                    ? h("span", { domProps: cancelHtml })
                    : stripTags(this.cancelTitle))
              ]
            );
          }
          const okHtml = this.okTitleHtml
            ? { innerHTML: this.okTitleHtml }
            : null;
          const okButton = h(
            NlyButton,
            {
              ref: "ok-button",
              props: {
                variant: this.okVariant,
                size: this.buttonSize,
                disabled: this.okDisabled || this.busy || this.isTransitioning
              },
              on: { click: this.onOk }
            },
            [
              // TODO: Rename slot to `ok-button` and deprecate `modal-ok`
              this.normalizeSlot("modal-ok") ||
                (okHtml
                  ? h("span", { domProps: okHtml })
                  : stripTags(this.okTitle))
            ]
          );
          modalFooter = [cancelButton, okButton];
        }
        footer = h(
          "footer",
          {
            ref: "footer",
            staticClass: "modal-footer",
            class: this.footerClasses,
            attrs: { id: this.safeId("__nlya_modal_footer_") }
          },
          [modalFooter]
        );
      }

      // Assemble modal content
      const modalContent = h(
        "div",
        {
          ref: "content",
          staticClass: "modal-content",
          class: this.contentClass,
          attrs: {
            role: "document",
            id: this.safeId("__nlya_modal_content_"),
            tabindex: "-1"
          }
        },
        [header, body, footer]
      );

      // Tab trap to prevent page from scrolling to next element in
      // tab index during enforce focus tab cycle
      let tabTrapTop = h();
      let tabTrapBottom = h();
      if (this.isVisible && !this.noEnforceFocus) {
        tabTrapTop = h("span", { ref: "topTrap", attrs: { tabindex: "0" } });
        tabTrapBottom = h("span", {
          ref: "bottomTrap",
          attrs: { tabindex: "0" }
        });
      }

      // Modal dialog wrapper
      const modalDialog = h(
        "div",
        {
          ref: "dialog",
          staticClass: "modal-dialog",
          class: this.dialogClasses,
          on: { mousedown: this.onDialogMousedown }
        },
        [tabTrapTop, modalContent, tabTrapBottom]
      );

      // Modal
      let modal = h(
        "div",
        {
          ref: "modal",
          staticClass: "modal",
          class: this.modalClasses,
          style: this.modalStyles,
          directives: [
            {
              name: "show",
              rawName: "v-show",
              value: this.isVisible,
              expression: "isVisible"
            }
          ],
          attrs: {
            id: this.safeId(),
            role: "dialog",
            "aria-hidden": this.isVisible ? null : "true",
            "aria-modal": this.isVisible ? "true" : null,
            "aria-label": this.ariaLabel,
            "aria-labelledby":
              this.hideHeader ||
              this.ariaLabel ||
              // TODO: Rename slot to `title` and deprecate `modal-title`
              !(
                this.hasNormalizedSlot("modal-title") ||
                this.titleHtml ||
                this.title
              )
                ? null
                : this.safeId("__nlya_modal_title_"),
            "aria-describedby": this.safeId("__nlya_modal_body_")
          },
          on: { keydown: this.onEsc, click: this.onClickOut }
        },
        [modalDialog]
      );

      // Wrap modal in transition
      // Sadly, we can't use nlyaTransition here due to the differences in
      // transition durations for .modal and .modal-dialog. Not until
      // issue https://github.com/vuejs/vue/issues/9986 is resolved
      modal = h(
        "transition",
        {
          props: {
            enterClass: "",
            enterToClass: "",
            enterActiveClass: "",
            leaveClass: "",
            leaveActiveClass: "",
            leaveToClass: ""
          },
          on: {
            beforeEnter: this.onBeforeEnter,
            enter: this.onEnter,
            afterEnter: this.onAfterEnter,
            beforeLeave: this.onBeforeLeave,
            leave: this.onLeave,
            afterLeave: this.onAfterLeave
          }
        },
        [modal]
      );

      // Modal backdrop
      let backdrop = h();
      if (!this.hideBackdrop && this.isVisible) {
        backdrop = h(
          "div",
          {
            staticClass: "modal-backdrop",
            attrs: { id: this.safeId("__nlya_modal_backdrop_") }
          },
          // TODO: Rename slot to `backdrop` and deprecate `modal-backdrop`
          [this.normalizeSlot("modal-backdrop")]
        );
      }
      backdrop = h(NlyToastTransition, { props: { noFade: this.noFade } }, [
        backdrop
      ]);

      // If the parent has a scoped style attribute, and the modal
      // is portalled, add the scoped attribute to the modal wrapper
      const scopedStyleAttrs = !this.static ? this.scopedStyleAttrs : {};

      // Assemble modal and backdrop in an outer <div>
      return h(
        "div",
        {
          key: `modal-outer-${this._uid}`,
          style: this.modalOuterStyle,
          attrs: {
            ...scopedStyleAttrs,
            ...this.$attrs,
            id: this.safeId("__nlya_modal_outer_")
          }
        },
        [modal, backdrop]
      );
    }
  },
  render(h) {
    if (this.static) {
      return this.lazy && this.isHidden ? h() : this.makeModal(h);
    } else {
      return this.isHidden ? h() : h(NlyTransporterSingle, [this.makeModal(h)]);
    }
  }
});
